Skip to content

fix(core): Overhaul worker shutdown to make sure all tasks are executed before terminating - #1704

Open
maciejdudko wants to merge 7 commits into
mainfrom
maciejdudko/poller-shutdown-fix
Open

maciejdudko wants to merge 7 commits into
mainfrom
maciejdudko/poller-shutdown-fix

Conversation

@maciejdudko

@maciejdudko maciejdudko commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What was changed

Changes in worker shutdown:

  • Worker now drains the Workflow stream completely before shutting down. This was already the behavior for Activity and Nexus pollers.
  • Made Poller::poll take &mut self to forbid concurrent polls.
  • Removed Poller::notify_shutdown as it was redundant with worker shutdown token.
  • When LongPollBuffer stops, it yields a single Some(PollerError::PollerStopped) before yielding None. This replaces inserting dead poller message in the wrapping streams.
  • ShutdownWorker RPC will retry for as long as pollers are still polling, to avoid long shutdown on transient RPC failure.
  • Replaced LocalInputs::BumpStream with WFStreamInput::CheckShutdown that doesn't clog the WFT input stream.
  • Eager workflow tasks are no longer dropped if worker shutdown started before processing server response.

Other changes in implementation:

  • Fixed a race condition in ClosableMeteredPermitDealer.
  • Added ThrottleRetryConfigForCall and RetryCallback request extensions for use by ShutdownWorker RPC.
  • Fixed tracing span propagation in workflows.
  • SlotKind trait now has bounds Send + Sync + 'static in the definition so that they don't have to be added ad-hoc.
  • Removed ExternalPollerInputs type since it's now redundant.

Changes in tests:

  • Replaced make_wft_stream_interminable with keep_wft_pending_until_worker_shutdown because interminable WFT stream now blocks shutdown.
  • Added setup_mock_worker_client and setup_manual_mock_worker_client to simplify overriding default mock client behavior.
  • Fixed a large number of tests that were broken by the changes or that had existing bugs that were exposed by the changes.

Why?

This PR addresses a number of bugs in worker shutdown procedure that caused lost tasks or lost task permits on shutdown, mostly caused by not draining the workflow task poller before finalizing shutdown. This was most prominent in a large number of spurious test failures when running locally, but it also has potential to impact users in real world production scenarios.

Checklist

  1. How was this tested:

Unit tests and integration tests were adapted to the new behavior.

@maciejdudko

maciejdudko commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

CI timed out:

test core_tests::activity_tasks::eviction_completion_during_shutdown_does_not_panic has been running for over 60 seconds

This is a spurious failure, didn't happen in the first run - may have introduced a new concurrency bug. I'm going to investigate. But the PR is still reviewable.

@Sushisource Sushisource self-assigned this Oct 27, 2026
@Sushisource
Sushisource self-requested a review October 27, 2026 22:29
Comment thread crates/sdk-core/src/worker/client.rs Resolved
Comment thread crates/sdk-core/src/worker/mod.rs Resolved
Comment thread crates/sdk-core/src/abstractions.rs Resolved
@maciejdudko

Copy link
Copy Markdown
Contributor Author

Pushed a commit that addresses all review feedback. Unfortunately there are merge conflicts that will take some effort to resolve, as both this PR and main branch made significant changes to the combined normal/sticky WFT poller.

Additionally, the eviction_completion_during_shutdown_does_not_panic failure is an actual bug. The new implementation has a regression where it's again possible for a race condition to occur between finalizing shutdown and completing evictions when ignore_evictions_on_shutdown is true. Haven't finished the fix.

@Sushisource

Sushisource commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Pushed a commit that addresses all review feedback. Unfortunately there are merge conflicts that will take some effort to resolve, as both this PR and main branch made significant changes to the combined normal/sticky WFT poller.

Additionally, the eviction_completion_during_shutdown_does_not_panic failure is an actual bug. The new implementation has a regression where it's again possible for a race condition to occur between finalizing shutdown and completing evictions when ignore_evictions_on_shutdown is true. Haven't finished the fix.

Does fixing #1704 not fix that test?

LLMs are pretty great at resolving merge conflicts too, would definitely try to lean on them there.

@Sushisource

Copy link
Copy Markdown
Member

@maciejdudko I think this would fix #1753

Plz check if it does and we can close at the same time

@maciejdudko

Copy link
Copy Markdown
Contributor Author

Does fixing #1704 not fix that test?

No, the new implementation of shutdown regresses on this test (although with stuck completion instead of error). That said, it's only when ignore_evicts_on_shutdown=true, so maybe we just disable that test for now as to not block this PR.

I think this would fix #1753

Yes, going to add #1753 to PR description.

Copy link
Copy Markdown
Member

Maybe we should just bite the bullet and get rid of ignore_evicts_on_shutdown and not allow it

@maciejdudko

Copy link
Copy Markdown
Contributor Author

Opened #1765 for removing ignore_evicts_on_shutdown. Added #[ignore] to the problematic test in this PR. It's now ready for re-review.

Comment on lines +60 to +73
/// Internal. Closure type for [`RetryCallback`].
pub(crate) trait RetryCallbackTrait:
FnMut(&tonic::Status) + DynClone + Send + Sync + 'static
{
}
impl<T> RetryCallbackTrait for T where T: FnMut(&tonic::Status) + Clone + Send + Sync + 'static {}
dyn_clone::clone_trait_object!(RetryCallbackTrait);

/// A request extension that obtains a permit from the provided semaphore before every attempt.
/// On a retryable error, the permit is released until next attempt. On the final response, whether
/// successful or an error, the permit is leaked (this can be used to implement cancellation logic
/// that only runs between attempts and not during a request and not after completion).
#[derive(Clone, Debug)]
pub struct RetryCallback(pub Box<dyn RetryCallbackTrait>);
pub struct AttemptSemaphore(pub Arc<Semaphore>);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this a fair amount. Right now it's easy to forget to use the semaphore properly and we could solve that by reversing the way it works. EX: If anyone forgets to pass in an attempt semaphore, that call can get interrupted immediately.

Rather than initializing it with a permit, we should init it with 0, then the permit gets added in TonicErrorHandler::wait_retry and every attempt after the first attempt acquires in the gRPC interceptor but wait to forget it until just before running.

Here's an AI sketch of the changes:

  1. crates/client/src/grpc.rs: only retries acquire a permit
let maybe_sem = req.extensions().get::<AttemptSemaphore>();
let mut is_first_attempt = true;

let fact = || {
    let req_clone = req_cloner(&req);
    let call_fut = callfn(req_clone);
    // The permit for a retry is the one TonicErrorHandler added when the previous attempt failed.
    let gate = (!std::mem::take(&mut is_first_attempt)).then_some(maybe_sem).flatten();
    async move {
        if let Some(sem) = gate {
            sem.0.acquire().await.expect("AttemptSemaphore must not be closed").forget();
        }
        call_fut.await
    }
};

make_future_retry takes an FnMut, so mutating the flag inside the closure is fine.

  1. crates/client/src/retry.rs: no change

wait_retry already does sem.add_permits(1) before returning WaitRetry. The ForwardError and success paths leave the count at 0, as they do now.

  1. crates/client/src/request_extensions.rs: update the doc
/// A request extension that exposes when a call is between retry attempts. The semaphore must start
/// with 0 permits. A permit is added after an attempt fails with a retryable error, and taken back
/// before the next attempt starts. Acquiring a permit from outside therefore succeeds only between
/// attempts, which is the only point at which the call can be abandoned without losing a response.
#[derive(Clone, Debug)]
pub struct AttemptSemaphore(pub Arc<Semaphore>);
  1. crates/sdk-core/src/pollers/poll_buffer.rs: start at 0, poll branch first
let sem = Arc::new(Semaphore::new(0));
(Some(sem.clone()), async move {
    // Only resolves between retry attempts, so no server response can be dropped.
    if let Ok(permit) = sem.acquire().await {
        permit.forget();
    }
}.left_future())
...
let r = tokio::select! {
    biased;
    r = pf(PollFnOptions { timeout_override, attempt_semaphore }) => r,
    _ = async { shutdown.cancelled().await; poll_interruptor.await } => return,
};

Putting the poll branch first also fixes another issue: a poll that has just completed wins the tie. That's safe in graceful mode, because the waiting interrupt branch is first in the semaphore's FIFO queue. When a retry adds a permit, it goes to the interrupt branch, not to the next attempt.

  1. Mocks and tests
  • Remove both "MUST lock the semaphore" notes in mocks.rs. Mocks that ignore the semaphore now behave like the old code: polls run to completion.
  • shutdown_blocks_until_shutdown_rpc_first_attempt_done: the mock no longer needs to touch the semaphore.
  • shutdown_proceeds_on_poll_retryable_error: simulate a retry with sem.add_permits(1) instead of try_acquire/drop.
  • Add one unit test in grpc.rs: the first attempt goes through with 0 permits, and the second waits until add_permits(1).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other thing we can do I think is eliminate RetryCallback entirely, and re-use the semaphore to deal with the same thing:

// initiate_shutdown
let between_attempts = Arc::new(Semaphore::new(0));
let join_handle = tokio::spawn(async move {
    client.shutdown_worker(sticky_name, task_queue, task_queue_types, heartbeat,
                           Some(between_attempts_clone)).await ...
});
*guard = Some(ShutdownRpcContext { join_handle, between_attempts });

// shutdown
select! {
    res = &mut ctx.join_handle => handle_join(res),
    _ = ctx.between_attempts.acquire() => {
        warn!("ShutdownWorker RPC still failing after pollers stopped; abandoning it");
        ctx.join_handle.abort();
        #[cfg(debug_assertions)]
        handle_join(ctx.join_handle.await);
    }
}

It does mean we will now possibly wait for an in-flight ShutdownWorker rpc to finish, but, that's totally fine.

Comment on lines +309 to +310
#[case::normal_ends_first(false)]
#[case::sticky_ends_first(false)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same case twice

// don't need to use the external stream
let stream = if !client.is_mock() {
let external_wft_slots = external_wft_slots.clone();
//let external_wft_slots = external_wft_slots.clone();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover code

// therefore we will have only polled twice.
pb.poll().await.unwrap().unwrap();
shutdown_token.cancel();
//shutdown_token.cancel();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover code

UnboundedReceiverStream::new(external_wft_rx)
.map(|(t, p)| Ok((t, Either::Right(p))))
.take_until(async move {
external_wft_slots.close_complete().await

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's still a problem here. Since PollerStopped gets passed straight through this, regardless of if the eager side is done, which means the poller gets marked stopped and can finish shutdown & drop external_wft_rx. Worth adding a regression test that tries to trigger an eager task getting dropped.

Can be fixed I think by doing the same thing we do for sticky poller: https://github.com/temporalio/sdk-rust/pull/1704/changes#diff-e5cb102b5d9c13362ffe79169f2f125a11add40b80095e1d8cf2221ec1fdd998R81

Perhaps we want to wrap that up in some shared helper

if state.tx.send(state.poller).is_err() {
Self::warn_channel_closed(state.is_sticky);
}
None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here we could just call state.poller.shutdown().await directly, which would allow us to just get rid of the oneshot channels entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would make StickyTaskPoller::shutdown not behave correctly, and downstream callers rely on it working.

Comment on lines 2181 to +2183
// A single slot keeps the second LA waiting for dispatch behind the first.
let core = la_cancel_test_worker(la_cancel_mock_cfg(), Some(1));
let mock_cfg = la_cancel_mock_cfg();
let core = la_cancel_test_worker(mock_cfg, None);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change makes the comment wrong now... why did we go from 1 -> None?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy-paste error. Will fix.

tokio::time::timeout(Duration::from_secs(5), drain_pollers_and_shutdown(&core))
.await
.expect("serviced queries must not prevent shutdown");
assert_eq!(core.available_wft_permits(), initial_permits);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did this assertion go away?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. But it seems to work fine with it added back, so I'll add it back.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants